// Copyright 2010 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.


/**
 * @fileoverview The ContentScript represents a set of CSS and JavaScript code
 * that will be injected into a page like content scripts for a Chrome
 * Extension.  This includes restricting injection to the successful matching
 * of the url and criteria defined for the object.
 *
 * The script is the qualified by states that represent when, where, and if
 * this unit should be injected into a web page.  The ContentScript includes
 * helper functions for creation of new objects, processing raw data, and
 * injection.  It also has a getStatus function for determining the load state
 * of the script.
 *
 * The ContentScript class is owned and managed through the Layer class.  This
 * means that the data properties of the class are publically available.
 *
 * Public functions:
 *   shellExtension.ContentScript - Constructs a new ContentScript object.  The
 *       initial state is BAD_OBJECT.
 *   shellExtension.ContentScript.create(data, baseUrl, log) - Create a new
 *       ContentScript object from the given data and return it.
 *
 * Public prototype functions for shellExtension.ContentScript.
 *   getStatus() - Returns the current status of the object and all of its
 *       dependencies.
 *   inject(id, log, tabId, url) - Injects the object's JavaScript and CSS into
 *       the specified page.  The function will check if injection should occur
 *       before taking action.
 *   doesMatch(url) - Determines whether or not one of the match expressions in
 *       the content script matches the given url.
 *
 * @author jasonstredwick@google.com (Jason Stredwick)
 */


goog.provide('shellExtension.ContentScript');

goog.require('bite.common.chrome.extension.urlMatching');
goog.require('bite.common.log.Simple');
goog.require('bite.common.net.url');
goog.require('shellExtension.ScriptSet');
goog.require('shellExtension.manifest.script');
goog.require('shellExtension.manifest.script.content');
goog.require('shellExtension.status');



/**
 * This class contains information about a content script.  See the file
 * overview for more information.
 * @constructor
 */
shellExtension.ContentScript = function() {
  /**
   * Determines if the content script should be injected into all frames for
   * a page or just the top frame.  This corresponds to the all_frames
   * property in the Chrome Extension manifest.
   * @type {boolean}
   */
  this.allFrames = false;

  /**
   * Matches is an array of RegExps used to determine if the content script
   * should be injected into a page.
   * @type {!Array.<RegExp>}
   */
  this.matches = [];

  /**
   * A string used to determine when to inject the script; corresponding to the
   * run_at property in a Chrome Extension manifest.  See the
   * shellExtension.manifest.script.content.RunAt for more details.
   * @type {shellExtension.manifest.script.content.RunAt}
   */
  this.runAt = shellExtension.manifest.script.content.RunAt.DOCUMENT_IDLE;

  /**
   * The script set will contain all the scripts associated with this content
   * script; both JavaScript and CSS.
   * @type {!shellExtension.ScriptSet}
   */
  this.scriptSet = new shellExtension.ScriptSet();

  /**
   * The status of the content script.
   * @type {shellExtension.status.Value}
   */
  this.status = shellExtension.status.Value.BAD_OBJECT;
};


/**
 * Create a new ContentScript with the given script data.  All errors will be
 * logged.  The new ContentScript is created and returned regardless of its
 * load status, which is important to the functioning of the layer.
 * @param {!Object} data Each component is optional and empty objects are
 *     acceptable.  This data will be validated as it could come from an
 *     external source.
 * @param {string} baseUrl The base url of all relative linked scripts.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @return {!shellExtension.ContentScript} The new ContentScript.
 */
shellExtension.ContentScript.create = function(data, baseUrl, log) {
  var script = new shellExtension.ContentScript();

  // Validate data to ensure that is a valid object.
  if (!shellExtension.manifest.script.content.validate(data)) {
    var msgBase = 'ERROR (shellExtension.ContentScript.create): ';
    log.add(msgBase + 'Invalid data, expecting object.');
    return script;
  }

  var failed = false;
  var contentProperty = shellExtension.manifest.script.content.Property;
  for (var propertyKey in contentProperty) {
    var key = contentProperty[propertyKey];
    if (!(key in data)) {
      continue;
    }

    var dataValue = data[key];

    var result = false;
    switch (key) {
      case contentProperty.ALL_FRAMES:
        result = script.addAllFrames_(dataValue, log);
        break;

      case contentProperty.MATCHES:
        result = script.addMatches_(dataValue, log);
        break;

      case contentProperty.RUN_AT:
        result = script.addRunAt_(dataValue, log);
        break;

      case contentProperty.CSS_CODE:
      case contentProperty.CSS_URL:
      case contentProperty.JS_CODE:
      case contentProperty.JS_URL:
        key = /** @type {shellExtension.manifest.script.Property} */ (key);
        result = script.addScripts_(baseUrl, dataValue, key, log);
        break;

      default:
        log.add('WARNING Unknown property in shellExtension.manifest.script.' +
                'content.Property (' + key + ').');
        break;
    }

    if (!result) {
      failed = true;
    }
  }

  failed || (script.status = shellExtension.status.Value.LOADED);

  return script;
};


/**
 * Process data and set the allFrames_ value.  Type and data validation are
 * necessary because the data source is external and may not have been
 * validated.
 * @param {boolean|string} data All frames data that is equivalent to the
 *     all_frames entry in a Chrome Extension manifest.  This data will be
 *     validated as it could come from an external source.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @return {boolean} Whether or not processing was a success.
 * @private
 */
shellExtension.ContentScript.prototype.addAllFrames_ = function(data, log) {
  if (!shellExtension.manifest.script.content.validateAllFrames(data)) {
    var msgBase = 'ERROR (shellExtension.ContentScript.addAllFrames_): ';
    log.add(msgBase + 'Invalid data.');
    return false;
  }

  if (typeof(data) == 'string') {
    this.allFrames = /** @type {string} */ (data) == 'true' ? true : false;
  } else {
    this.allFrames = /** @type {boolean} */ (data);
  }

  return true;
};


/**
 * Converts Chrome Extension match patterns to RegExps and then fills the
 * matches array with them.  If an error occurs then the list of matches is set
 * to empty meaning match nothing.
 * @param {!Array.<string>} data Matches data that is equivalent to the matches
 *     entry in a Chrome Extension manifest.  This data will be validated as it
 *     could come from an external source.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @return {boolean} Returns whether or not an error occurred.
 * @private
 */
shellExtension.ContentScript.prototype.addMatches_ = function(data, log) {
  var msgBase = 'ERROR (shellExtension.ContentScript.addMatches_): ';

  if (!shellExtension.manifest.script.content.validateMatches(data)) {
    log.add(msgBase + 'Invalid data.');
    return false;
  }

  for (var i = 0, len = data.length; i < len; ++i) {
    var match = data[i];

    var urlMatching = bite.common.chrome.extension.urlMatching;
    try {
      var regexp = urlMatching.convertToRegExp(match);
      if (!regexp) {
        throw 'Invalid match expression given: ' + match;
      }

      this.matches.push(regexp);
    } catch (error) {
      log.add(msgBase + 'Exception for match[' + i + ']: ' + error);
      this.matches = [];
      return false;
    }
  }

  return true;
};


/**
 * Process "when" data and set the corresponding value for the given script.
 * @param {shellExtension.manifest.script.content.RunAt} data Matches data that
 *     is equivalent to the run_at entry in a Chrome Extension manifest.  This
 *     data will be validated as it could come from an external source.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @return {boolean} Whether or not processing was successful.
 * @private
 */
shellExtension.ContentScript.prototype.addRunAt_ = function(data, log) {
  var msgBase = 'ERROR (shellExtension.ContentScript.addRunAt_): ';

  if (!shellExtension.manifest.script.content.validateRunAt(data)) {
    log.add(msgBase + 'Invalid data.');
    return false;
  }

  this.runAt = data;
  return true;
};


/**
 * Add scripts to the ScriptSet using information from data.
 * @param {string} baseUrl The base url of all relative linked scripts.
 * @param {!Array.<string>} data Each entry contains information related to a
 *     single script; either all raw code or all urls.  This data will be
 *     validated as it could come from an external source.
 * @param {shellExtension.manifest.script.Property} type The type of script.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @return {boolean} Whether or not processing was successful.
 * @private
 */
shellExtension.ContentScript.prototype.addScripts_ = function(baseUrl, data,
                                                              type, log) {
  var msgBase = 'ERROR (shellExtension.ContentScript.addScripts_): ';

  // Validate data to ensure that it is array-like set of strings.
  if (!shellExtension.manifest.script.validateList(data)) {
    log.add(msgBase + 'Invalid data.');
    return false;
  }

  var scriptProperty = shellExtension.manifest.script.Property;
  var isUrl = type == scriptProperty.JS_URL || type == scriptProperty.CSS_URL;

  for (var i = 0, len = data.length; i < len; ++i) {
    var value = data[i];
    var id = isUrl ? bite.common.net.url.toAbsolute(value, baseUrl) : null;
    var scriptData = isUrl ? undefined : value;

    this.scriptSet.add(id, type, log, scriptData);
  }

  return true;
};


/**
 * Clears the script set's data; i.e. all loadable scripts are set to their
 * INIT state and pending.
 */
shellExtension.ContentScript.prototype.clearData = function() {
  this.scriptSet.clearData();
};


/**
 * Examines the set of regular expressions for the content script and compares
 * them to the given url and returns true if they match.
 * @param {string} url The url that needs validating.
 * @return {boolean} Whether the url matches one of the regular expressions.
 */
shellExtension.ContentScript.prototype.doesMatch = function(url) {
  // If url is <all_urls> then url matches no matter what.  Except in the case
  // where there are no matches, then doesMatch is guaranteed false.
  if (this.matches.length == 0) {
    return false;
  }

  // Test to see if the url is the special case <all_urls> and automatically
  // return true if it is.
  var urlMatching = bite.common.chrome.extension.urlMatching;
  if (urlMatching.Regexp.ALL_EXPRESSIONS_TRUE.test(url)) {
    return true;
  }

  // Check against all match expressions.
  for (var i = 0, len = this.matches.length; i < len; ++i) {
    if (this.matches[i].test(url)) {
      return true;
    }
  }

  return false;
};


/**
 * Retrieves the status of the content script, which is BAD_OBJECT or the
 * status of its ScriptSet.
 * @return {shellExtension.status.Value} The status.
 */
shellExtension.ContentScript.prototype.getStatus = function() {
  if (this.status == shellExtension.status.Value.BAD_OBJECT) {
    return this.status;
  }

  return this.scriptSet.getStatus();
};


/**
 * Inject any available javascript or css code into the page associated with
 * the tabId.  If the ScriptSet is not loaded then nothing is injected.
 * @param {string} id An id used to identify this content script.
 * @param {!bite.common.log.Simple} log The log to record errors.
 * @param {number} tabId The tab id for the page to inject into.
 * @param {string} url The url to check before injection.
 */
shellExtension.ContentScript.prototype.inject = function(id, log, tabId, url) {
  if (!this.doesMatch(url)) {
    return;
  }

  var glob = this.scriptSet.getGlobs(log);
  if (!glob) {
    return;
  }

  var css = glob[shellExtension.manifest.script.Property.CSS_CODE];
  var js = glob[shellExtension.manifest.script.Property.JS_CODE];

  if (css) {
    var cssObj = {code: css, allFrames: this.allFrames};
    chrome.tabs.insertCSS(tabId, cssObj);
  }

  var msgBase = 'ERROR (shellExtension.ContentScript.inject): ';
  if (js) {
    // Wrap the javascript code in a try/catch block before injecting.  This
    // helps debugging injected code.
    js = 'try {\n' + js + '\n} catch (error) {\n' +
         '  console.error("' + msgBase + ' [id=' + id + ']: " + error);\n' +
         '}\n';
    var jsObj = {code: js, allFrames: this.allFrames};
    chrome.tabs.executeScript(tabId, jsObj);
  }
};

